iT邦幫忙

2024 iThome 鐵人賽

DAY 21
0
JavaScript

從PM到前端開發:我的React作品集之旅系列 第 21

Day 21 : 用 React 實現單頁應用的平滑滾動導航

  • 分享至 

  • xImage
  •  

在前一篇文章中,我們討論了如何動態生成 Card 元件 並構建了基礎的 Service Section,並利用 CSS Grid 實現了彈性且響應式的卡片佈局。今天,我們將繼續優化這個部分,探討如何在 React 單頁應用 中實現 平滑滾動與自動導航高亮 功能。透過這些技術,我們可以讓用戶點擊導航菜單時,頁面自動滾動到對應的區塊,並在滾動時自動高亮顯示當前區塊的導航項目。

單頁應用 (SPA) 與多頁應用 (MPA) 的區別

單頁式應用和多頁式應用各自有不同的優勢和適用場景,下面是它們的簡單對比:

特點 單頁應用 (SPA) 多頁應用 (MPA)
頁面加載 頁面只加載一次,內容通過 JavaScript 更新 每次頁面跳轉都會重新加載整個頁面
用戶體驗 提供更流暢的頁面切換體驗,減少頁面加載時間 可能會導致頁面間跳轉有延遲
SEO 支援 需要額外設置才能確保搜索引擎友好 默認情況下對搜索引擎友好
性能 適合內容變化頻繁的應用,但可能在首屏加載較慢 適合大型網站或多頁應用,首屏加載快,但跳轉慢
滾動與導航 可以利用平滑滾動和滾動監控技術模擬多頁切換效果 每次跳轉都加載新頁面,不需要特殊的滾動控制

目標

在這篇文章中,我們將實現以下功能:

  1. 多區塊滾動:不僅是 "Services" 區塊,還包括 "Home" 和其他區塊(如 "Projects"、"Contact")。
  2. 平滑滾動:當用戶點擊導航菜單中的任何項目,頁面會平滑滾動到相應的區塊,提升用戶體驗。
  3. 自動高亮導航:當用戶滾動到某一區塊時,導航欄中的對應項目會自動高亮,讓用戶知道當前所在的位置。

實際演練

在第19天文章,我們已經為HeroSectionServiceSection設置一個唯一的 id,這樣才能讓頁面根據導航的點擊進行滾動。接下來,我們將展示如何使用 React 實現單頁應用的平滑滾動導航。整個過程將分為三個步驟來完成,並在每個步驟中解釋具體的技術細節。

Step 1: 解決導航欄滾動消失問題

在單頁應用中,我們經常會遇到導航欄隨頁面滾動而消失的情況。為了提升用戶體驗,我們希望導航欄在滾動過程中保持可見,這樣用戶無需回到頂部即可快速導航。

解決方案是使用 position: sticky,這是一個常用的 CSS 屬性,適合在滾動到某一位置時將元素固定在視口內。

//src/components/Layout.module.scss
header {
    position: sticky;
    height: 60px;
    top: 0;
    width: 100%;
    z-index: 100;
    background-color: var(--background-primary);
}

說明:

  • position: sticky:當滾動到設置的 top 值時,導航欄將固定在視口頂部。這樣可以讓導航欄在滾動過程中保持可見,而未滾動到該位置前,它仍然像普通元素一樣滾動。
  • z-index:設置為 100,確保導航欄在滾動過程中不會被其他元素遮蓋。

這樣,導航欄就不會隨著頁面的滾動而消失,提升了用戶的操作便利性。

Step 2: 新增滾動函數,實現平滑滾動

接下來,我們將實現一個名為 handleScroll 的滾動處理函數,該函數可以在點擊導航欄後,將頁面平滑滾動至對應區塊。通過結合 scrollIntoViewwindow.scrollTo 兩種方法,我們能夠精確控制滾動行為,並避免目標區塊被固定導航欄遮擋。

    // 定義滾動函數,實現平滑滾動並解決導航欄遮擋問題
    const handleScroll = (id) => {
        const element = document.getElementById(id);
        if (id === 'home') {
            window.scrollTo({
                top: 0,  // 滾動到頁面最上方
                behavior: 'smooth'  // 平滑滾動效果
            });
        } else {
            const element = document.getElementById(id);
            if (element) {
                // 手動計算滾動位置,避免被導航欄遮擋
                const elementPosition = element.getBoundingClientRect().top + window.scrollY - 80;
                window.scrollTo({
                    top: elementPosition,
                    behavior: 'smooth',
                });
            }
        }
    };

說明:

  • window.scrollTo():對於點擊 "Home" 的情況,頁面將平滑滾動至頂部。
  • getBoundingClientRect().top + window.scrollY:這段程式碼計算出目標元素在整個頁面中的絕對位置,並將滾動位置調整,以避免被固定的導航欄遮擋。
  • behavior: 'smooth':此選項啟用平滑滾動,讓滾動過程更加自然,增強用戶體驗。

透過這些設定,點擊導航項目後頁面將順暢地滾動至目標區塊,同時確保目標區塊不會被固定的導航欄隱藏。

Step 3: 使用 useScrollSpy 自動切換導航項目

接著,透過一個自定義的 Hook useScrollSpy 來監控頁面滾動並自動更新導航的狀態。這主要依賴於 IntersectionObserver API,來監控頁面上的各個區塊,當某個區塊進入視口時,導航欄對應的項目會自動高亮顯示。

// src/util/useScrollSpy.js

import { useEffect } from 'react';

const useScrollSpy = (setActiveLink) => {
    useEffect(() => {
        const sectionIds = ['home', 'services', 'projects', 'contact'];
        const sections = sectionIds.map(id => document.getElementById(id));

        const observer = new IntersectionObserver(
            (entries) => {
                entries.forEach(entry => {
                    if (entry.isIntersecting) {
                        const index = sectionIds.indexOf(entry.target.id);
                        setActiveLink(index);  // 根據進入視口的區塊更新菜單項目高亮
                    }
                });
            },
            { threshold: 0.6 }  // 60% 區塊可見時觸發
        );

        sections.forEach(section => {
            if (section) observer.observe(section);
        });

        return () => {
            sections.forEach(section => {
                if (section) observer.unobserve(section);
            });
        };
    }, [setActiveLink]);
};

export default useScrollSpy;

說明

  • IntersectionObserver:通過觀察頁面中的區塊(如 "home"、"services" 等),當某個區塊進入視口時,自動高亮對應的導航項目。
  • 觸發條件threshold: 0.6 表示當區塊 60% 進入視口時觸發觀察器。

Step 4: 更新 Navbar,實現平滑滾動與自動高亮

接下來,將滾動處理函數和自動高亮useScrollSpy 應用到導航欄中。當用戶點擊某個項目時,觸發滾動,並在滾動到對應區塊時自動高亮對應的導航項。

//src/components/navBar/navBar.jsx 
import React, { useState } from 'react'
import ThemeButton from '@/components/navBar/ThemeButton';
import * as styles from '@/components/navBar/Navbar.module.scss';
import LangButton from '@/components/navBar/LangButton';
import useScrollSpy from '@/utils/useScrollSpy';

const Navbar = () => {
    const [isMenuOpen, setIsMenuOpen] = useState(false); // 控制菜單開關
    const [activeLink, setActiveLink] = useState(0); // 控制當前選中的菜單項目

    // 定義選單項目
    const menuItems = [
        { name: 'Home', link: '#home' },
        { name: 'Services', link: '#services' },
        { name: 'Projects', link: '#projects' },
        { name: 'Contact', link: '#contact' }
    ];

    // 切換漢堡菜單的開關狀態
    const toggleMenu = () => {
        setIsMenuOpen(!isMenuOpen);
    };

    // 使用自定義 hook 來監控滾動
    useScrollSpy(setActiveLink);

    // 定義滾動並設置激活狀態的函數
    const handleMenuClick = (e, index, link) => {
        e.preventDefault();  // 防止默認的錨點行為
        setActiveLink(index);  // 設定激活的菜單項目
        handleScroll(link);  // 滾動到對應區塊
    };

    // 定義滾動函數,實現平滑滾動
    const handleScroll = (id) => {
        if (id === 'home') {
            window.scrollTo({
                top: 0,  // 滾動到頁面最上方
                behavior: 'smooth'  // 平滑滾動效果
            });
        } else {
            const element = document.getElementById(id);
            if (element) {
                element.scrollIntoView({ behavior: 'smooth' });
            }
        }
    };

    return (
        <div className={styles.navbar} >
            {/* 導航欄品牌區域 */}
            <div className={styles.navbar_brand}>
                <a href="/" className="navbar-brand">
                    <div className={styles.navbar_logo} />
                </a>
            </div>

            {/* 側邊菜單區域 */}
            <div className={`${styles.sideMenu} ${isMenuOpen && styles.menuOpen}`}>
                <div className={styles.navbarLinks}>
                    {menuItems.map((item, index) => (
                        <li key={index} >
                            <a
                                href={item.link}
                                className={activeLink === index ? styles.active : ''}
                                onClick={(e) => handleMenuClick(e, index, item.link.substring(1))}  // 呼叫獨立函數
                            >
                                {item.name}
                            </a>
                        </li>
                    ))}
                </div>
                {/* 主題切換按鈕 */}
                <ThemeButton className={styles.themeButton} />
                <LangButton />
            </div>
            {/* 漢堡菜單按鈕 */}
            <button className={styles.hamburgerMenu} onClick={toggleMenu}>
                {isMenuOpen ? '✕' : '☰'}
            </button>
        </div >
    )
}

export default Navbar

結語

在這篇文章中,我們展示了如何實現 React 單頁應用的平滑滾動與導航高亮。透過 scrollIntoViewIntersectionObserver,我們不僅提升了頁面的流暢性,還讓用戶在滾動時能夠輕鬆跟蹤當前所在的位置。

完整Service Section程式碼已上傳至 GitHub,歡迎查看並進行進一步的學習與優化。
👉 前往 GitHub 的 v0.16.0-service-section-using-card 查看完整程式碼。


流光館Luma<∕> ✨ 期待與你繼續探索更多技術知識!



上一篇
Day 20: CSS Grid 實戰,打造彈性卡片佈局
下一篇
Day 22: 自製 Swiper UI 組件,打造靈活的專案展示頁面
系列文
從PM到前端開發:我的React作品集之旅30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言